并发难 | 池里究竟该放多少线程?

最近在项目里做了一个数据同步的功能。需要把两个数据库里的数据做同步,在业务不繁忙的时候跑一次,核对库里每一条数据,主要是为了保证数据最终一致,做的一个保底工作。之前项目里同步的代码是单线程的,我改成了基于线程池的,但是在上线前的一些问题却引起了我的思考。

现象

程序的代码还是比较简单的,大概类似下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 两个参数分别为线程数量和总任务数
p := pool.NewPool(goroutineCount, len(userIds))
for _, userId := range userIds {
p.Queue(func(job *pool.Job) {
userId := job.Params()[0].(int64)

tx, err := sess.Begin()
if err != nil {
panic(err)
}
defer tx.RollbackUnlessCommitted()
if err := dao.SyncSingleUser(tx, userId); err != nil {
panic(err)
}

if err := tx.Commit(); err != nil {
panic(err)
}
}, userId)
}
errOccurs := false
for result := range p.Results() {
if err, ok := result.(*pool.ErrRecovery); ok {
glog.Errorln(err)
errOccurs = true
}
}
if errOccurs {
return
}

为了确定线程的数量,我逐步增加线程数记录系统各项数值。下面是在一台 RMBP 上做的测试。

| T | QPS | CPU | DBCPU |
| | | | | |
| 1 | 9000 | 37 | 81 |
| 2 | 14000 | 60 | 150 |
| 3 | 15500 | 70 | 195 |
| 4 | 16500 | 90 | 260 |

可以发现随着线程数增加,性能会逐步提高,但是当线程数提高到一定程度之后,性能不增反降(这部分数据没放)。如果继续增加线程数进行测试,会发现系统的某些性能指标会被耗尽,程序开始报错。

1
recovering from panic: dial tcp 127.0.0.1:3306: getsockopt: operation timed out

为什么会出现这种情况呢?在讨论这些问题之前,先回顾一下 goroutine 的设计与实现。

goroutine 设计

事实上,在 golang 中的 goroutine 已经不是传统意义上的线程了。传统的操作系统提供的线程在使用时有一些限制,超过一定数量之后会对性能造成影响。而 golang 提供的 goroutine 是一种 green thread。

Green thread 和 NodeJS 的异步回调方案有类似的地方,都在底层调用的系统的异步 IO 系统调用。

  • 异步回调方案:所有 IO 操作的 API 都设计成异步回调形式,底层调用异步的系统调用(如epool、kqueue等)。同时,也可能提供同步版本的 IO 操作(如fs.readFileSync)
  • Green thread 方案:所有 IO 操作底层实际上事异步调用,但是在语言中却表现的像一个同步调用。当 IO 操作需要等待时,语言的 runtime 自动调度系统级线程到另一个 Green thread 中。写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但程序对此无感。

由此可见,Green thread 在语言设计上比异步回调方案要略胜一筹,不会出现“冲击波”的现象(具体在 NodeJS 中如何避免“冲击波”代码可以看我这篇文章await & async 深度剖析),但在性能上实际是差不多的,都避免了大量使用操作系统级的线程带来的性能问题,同时又能充分的利用 CPU。

但是,今天我想谈的问题并不是 CPU 利用率/线程数量的问题,这个问题已经被上述两种设计方案比较完美的解决了。

CPU 以外的资源瓶颈

在实际中,更多遇到的是 cpu /线程数量之外的资源瓶颈。比如锁、数据库链接、tcp链接。举个例子,假如做个爬虫应用,每个 goroutine 爬一个网页,golang 虽然号称百万级别的 goroutine ,但是你每个 goroutine 里面创建一个 tcp 链接,不到 5 万个 goroutine 就会把系统的 tcp 资源耗尽。

回到文章最上面的情况,在 goroutine 里调用了 dao.SyncSingleUser 函数,这个是一个数据库操作,不可避免的会有 socket 操作、硬盘操作。如果将所有数据库操作都无脑的放到 goroutine 中执行,当资源出现瓶颈之后,大量 goroutine 会阻塞或报错。

因此,我在项目里使用了一个 goroutine 池,来确保不会过多使用系统资源导致崩溃。那么问题来了,池里究竟该放多少线程

又回到了最初使用系统线程时遇到的问题,不过这次导致问题的不再是线程资源,而是其他资源瓶颈(如 tcp、数据库链接 等)。这些资源比线程资源更加复杂,更加难以把控。更加难做 benchmark,也就更难找出一个通用的方法来解决实际场景的问题。

没有银弹

思考过后,我在网上开始搜索解决方案。发现这篇文章的作者和我做了类似的思考:《并发之痛 Thread,Goroutine,Actor》。作者最后得出 Actor 模型能解决一些问题(不过我才疏学浅至今不怎么理解 Actor 模型),但并发带来的问题还远远没到解决的程度。

革命尚未成功 同志任需努力

所以说,软件工程没有银弹,路漫漫其修远兮,吾将上下而求索。

Proudly powered by Hexo and Theme by Hacker
© 2021 wastecat